#!/bin/bash
# npqvpn-dns-helper — NPQ VPN split DNS on Linux, run as root through pkexec.
#
# WHY IT EXISTS
#
#   Split DNS needs root (a systemd-resolved drop-in in /etc plus `resolvectl`
#   link settings), and the app used to get it with `pkexec sh -c <script>`.
#   pkexec's generic action asks for the admin password EVERY time, so users
#   typed it at every connect and disconnect. The package now ships this helper
#   (root-owned, under /opt) and a polkit action that names it by path
#   (/usr/share/polkit-1/actions/com.naoris.npqvpn.policy), allowing an ACTIVE
#   local session to run it without a password. Nothing else is authorised:
#   pkexec matches the action on this exact executable path.
#
# WHY IT VALIDATES EVERYTHING
#
#   Without a password, any process of the logged-in user can run this. So the
#   helper takes values, never commands. It writes its own fixed-format
#   drop-in, and refuses anything outside the contract:
#
#     • the interface must be NPQ VPN's own (npq0..npq9) and up;
#     • domains are DNS suffixes (the app's own pattern), at most 32;
#     • resolvers are IP addresses, at most 8;
#     • every INTRANET resolver must be routed THROUGH that interface
#       (`ip route get`). This is the line that matters: internal suffixes can
#       only ever be sent to a resolver inside the VPN, never to one on the
#       LAN or the Internet, so the helper cannot be used to hijack a domain's
#       DNS towards an attacker's server.
#
#   The link resolvers (`~.` on the tunnel link) are only ever applied to that
#   link, whose sockets systemd-resolved binds to the tunnel interface.
#
# CONTRACT (argv only — no stdin, no environment)
#
#   npqvpn-dns-helper apply  --iface npq0 --domains a.corp,b.corp --resolvers 10.0.0.53[,…] [--link-resolvers 1.1.1.1[,…]]
#   npqvpn-dns-helper remove --iface npq0
#
# The layout (two systemd-resolved scopes, `DNS=` reset first, `~` routing-only
# domains) is the app's; see electron/services/vpn/splitDns.ts, "Linux —
# systemd-resolved", for why each piece is there. Exit 2 = refused input.

set -euo pipefail
umask 022
export PATH=/usr/sbin:/usr/bin:/sbin:/bin

DROPIN_DIR=/etc/systemd/resolved.conf.d
DROPIN="$DROPIN_DIR/99-npqvpn-split-dns.conf"
MAX_DOMAINS=32
MAX_RESOLVERS=8

die() { echo "npqvpn-dns-helper: $*" >&2; exit 2; }

valid_iface() { [[ $1 =~ ^npq[0-9]$ ]]; }

# Same shape as the app's DNS_SUFFIX: labels of [a-z0-9-], no leading hyphen.
valid_domain() {
  (( ${#1} >= 1 && ${#1} <= 253 )) || return 1
  [[ $1 =~ ^[a-z0-9][a-z0-9-]{0,62}(\.[a-z0-9][a-z0-9-]{0,62})*$ ]]
}

valid_ipv4() {
  [[ $1 =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || return 1
  local IFS=. octet
  for octet in $1; do (( 10#$octet <= 255 )) || return 1; done
}

valid_ipv6() { [[ $1 == *:* && $1 =~ ^[0-9a-fA-F:]{2,39}$ ]]; }

valid_ip() { valid_ipv4 "$1" || valid_ipv6 "$1"; }

# Is this address routed out through the given interface right now?
routes_via() {
  local ip=$1 iface=$2 out
  if [[ $ip == *:* ]]; then
    out=$(ip -6 route get "$ip" 2>/dev/null) || return 1
  else
    out=$(ip -4 route get "$ip" 2>/dev/null) || return 1
  fi
  [[ " $out " == *" dev $iface "* ]]
}

split_csv() { local IFS=,; read -r -a "$2" <<< "$1"; }

reload_resolved() { systemctl try-reload-or-restart systemd-resolved; }

# ── arguments ───────────────────────────────────────────────────────────────

cmd=${1:-}
[[ $cmd == apply || $cmd == remove ]] || die "usage: apply|remove --iface npqN …"
shift

iface="" domains_csv="" resolvers_csv="" link_csv=""
while (( $# > 0 )); do
  [[ $# -ge 2 ]] || die "missing value for $1"
  case $1 in
    --iface)          iface=$2 ;;
    --domains)        domains_csv=$2 ;;
    --resolvers)      resolvers_csv=$2 ;;
    --link-resolvers) link_csv=$2 ;;
    *)                die "unknown argument" ;;
  esac
  shift 2
done

valid_iface "$iface" || die "refused interface"

# ── remove ──────────────────────────────────────────────────────────────────

if [[ $cmd == remove ]]; then
  # Every step is conditional: removal also runs at app start for a session that
  # may never have installed anything, and must not fail on "nothing to remove".
  if [[ -e $DROPIN ]]; then
    rm -f "$DROPIN"
    reload_resolved || true
  fi
  if [[ -e /sys/class/net/$iface ]]; then
    resolvectl revert "$iface" 2>/dev/null || true
  fi
  resolvectl flush-caches 2>/dev/null || true
  exit 0
fi

# ── apply ───────────────────────────────────────────────────────────────────

[[ -e /sys/class/net/$iface ]] || die "interface $iface is not up"

declare -a domains=() resolvers=() link=()
split_csv "$domains_csv" domains
split_csv "$resolvers_csv" resolvers
[[ -n $link_csv ]] && split_csv "$link_csv" link

(( ${#domains[@]} >= 1 && ${#domains[@]} <= MAX_DOMAINS )) || die "refused domain count"
(( ${#resolvers[@]} >= 1 && ${#resolvers[@]} <= MAX_RESOLVERS )) || die "refused resolver count"
(( ${#link[@]} <= MAX_RESOLVERS )) || die "refused link resolver count"

routing_domains=()
for d in "${domains[@]}"; do
  d=${d,,}
  valid_domain "$d" || die "refused domain"
  routing_domains+=("~$d")
done
for r in "${resolvers[@]}"; do
  valid_ip "$r" || die "refused resolver"
  routes_via "$r" "$iface" || die "resolver $r is not routed through $iface"
done
for r in "${link[@]}"; do
  valid_ip "$r" || die "refused link resolver"
done

# Written to a temporary file in the same directory, then renamed, so resolved
# never reads a half-written drop-in.
mkdir -p "$DROPIN_DIR"
tmp=$(mktemp "$DROPIN_DIR/.npqvpn.XXXXXX")
trap 'rm -f "$tmp"' EXIT
{
  echo "# Written by NPQ VPN (NPQVPN) for the connected session; removed on disconnect."
  echo "[Resolve]"
  echo "DNS="
  echo "DNS=${resolvers[*]}"
  echo "Domains=${routing_domains[*]}"
} > "$tmp"
chmod 0644 "$tmp"
mv -f "$tmp" "$DROPIN"
trap - EXIT

reload_resolved
if (( ${#link[@]} > 0 )); then
  resolvectl dns "$iface" "${link[@]}"
  resolvectl domain "$iface" '~.'
fi
resolvectl flush-caches
